feat: GenericGraphQlPlugin builder, AuthPort, proactive cost throttling (v0.1.12)#15
feat: GenericGraphQlPlugin builder, AuthPort, proactive cost throttling (v0.1.12)#15greysquirr3l merged 7 commits intomainfrom
Conversation
- Add AuthPort trait + ErasedAuthPort (object-safe blanket impl) + EnvAuthPort; resolve_token helper handles expiry detection and refresh automatically - Add CostThrottleConfig (port layer), LiveBudget / PluginBudget with pre_flight_delay, update_budget, and reactive_backoff_ms for proactive point-budget management on cost-bearing GraphQL APIs - Add GenericGraphQlPlugin + GenericGraphQlPluginBuilder — fluent builder API to construct a GraphQlTargetPlugin without writing a dedicated struct; supports name, endpoint, bearer_auth, auth, headers, cost_throttle, page_size, description - Wire auth_port and per-plugin budgets into GraphQlService; token resolved lazily per request, budget updated from extensions.cost.throttleStatus - Remove JobberPlugin and jobber_integration tests — consumer-specific plugins belong in the consuming application; use GenericGraphQlPlugin instead - Add GitHub GraphQL example pipe- Add GitHub GraphQL example pipe- Add GitHub GraphQL example pipe- Add GitHub GraphQLd GraphQL section, SUMMARY.md link - Bump version 0.1.11 → 0.1.12
There was a problem hiding this comment.
Pull request overview
This PR bumps the workspace to v0.1.12 and expands stygian-graph’s GraphQL support with (1) runtime credential injection via an auth port and (2) opt-in proactive cost throttling, while replacing the library’s consumer-specific Jobber plugin/examples with GitHub GraphQL example pipelines and updated mdBook docs.
Changes:
- Add
AuthPort/ErasedAuthPort(+EnvAuthPortandresolve_token) and wire optional runtime auth intoGraphQlService. - Add proactive cost throttling (
CostThrottleConfig,LiveBudget/PluginBudget,pre_flight_delay,update_budget,reactive_backoff_ms) and expose opt-in viaGraphQlTargetPlugin::cost_throttle_config(). - Replace Jobber-specific plugin/tests/examples with
GenericGraphQlPluginbuilder + new GitHub example pipelines, plus mdBook docs updates.
Reviewed changes
Copilot reviewed 42 out of 43 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| examples/pipelines/jobber/visits.toml | Remove Jobber visits pipeline example. |
| examples/pipelines/jobber/schemas/visit.schema.json | Remove Jobber Visit JSON schema example. |
| examples/pipelines/jobber/schemas/quote.schema.json | Remove Jobber Quote JSON schema example. |
| examples/pipelines/jobber/schemas/job.schema.json | Remove Jobber Job JSON schema example. |
| examples/pipelines/jobber/schemas/invoice.schema.json | Remove Jobber Invoice JSON schema example. |
| examples/pipelines/jobber/schemas/expense.schema.json | Remove Jobber Expense JSON schema example. |
| examples/pipelines/jobber/schemas/client.schema.json | Remove Jobber Client JSON schema example. |
| examples/pipelines/jobber/quotes.toml | Remove Jobber quotes pipeline example. |
| examples/pipelines/jobber/jobs.toml | Remove Jobber jobs pipeline example. |
| examples/pipelines/jobber/invoices.toml | Remove Jobber invoices pipeline example. |
| examples/pipelines/jobber/introspect.toml | Remove Jobber schema introspection example. |
| examples/pipelines/jobber/full_sync.toml | Remove Jobber full DAG sync example. |
| examples/pipelines/jobber/expenses.toml | Remove Jobber expenses pipeline example. |
| examples/pipelines/jobber/clients.toml | Remove Jobber clients pipeline example. |
| examples/pipelines/jobber/README.md | Remove Jobber pipeline documentation. |
| examples/pipelines/github/starred.toml | Add GitHub “starred repositories” GraphQL pipeline example. |
| examples/pipelines/github/schemas/repository.schema.json | Add GitHub repository JSON schema for normalisation. |
| examples/pipelines/github/schemas/pull_request.schema.json | Add GitHub pull request JSON schema for normalisation. |
| examples/pipelines/github/schemas/profile_summary.schema.json | Add JSON schema for AI-generated GitHub profile summary. |
| examples/pipelines/github/schemas/issue.schema.json | Add GitHub issue JSON schema for normalisation. |
| examples/pipelines/github/repositories.toml | Add GitHub owned repositories pipeline example. |
| examples/pipelines/github/pull_requests.toml | Add GitHub pull requests pipeline example. |
| examples/pipelines/github/issues.toml | Add GitHub issues pipeline example. |
| examples/pipelines/github/introspect.toml | Add GitHub schema introspection pipeline example. |
| examples/pipelines/github/full_sync.toml | Add GitHub full sync DAG example with Claude analysis node. |
| examples/pipelines/github/README.md | Add GitHub pipeline documentation. |
| crates/stygian-graph/tests/jobber_integration.rs | Remove live Jobber integration test. |
| crates/stygian-graph/src/ports/graphql_plugin.rs | Add CostThrottleConfig and plugin opt-in hook cost_throttle_config(). |
| crates/stygian-graph/src/ports/auth.rs | Add auth port traits, helpers, env implementation, and tests. |
| crates/stygian-graph/src/ports.rs | Export the new auth module. |
| crates/stygian-graph/src/adapters/graphql_throttle.rs | Add proactive throttle tracking + backoff utilities and tests. |
| crates/stygian-graph/src/adapters/graphql_plugins/mod.rs | Remove Jobber plugin module; document + expose generic plugin module. |
| crates/stygian-graph/src/adapters/graphql_plugins/jobber.rs | Remove Jobber plugin implementation. |
| crates/stygian-graph/src/adapters/graphql_plugins/generic.rs | Add GenericGraphQlPlugin and fluent builder + tests. |
| crates/stygian-graph/src/adapters/graphql.rs | Wire in ErasedAuthPort fallback and per-plugin budgets + budget updates. |
| crates/stygian-graph/src/adapters.rs | Export the new GraphQL throttling adapter module. |
| book/src/graph/graphql-plugins.md | Add mdBook page documenting plugins, auth port, and cost throttling. |
| book/src/graph/adapters.md | Add GraphQL adapter section linking to the plugin docs. |
| book/src/SUMMARY.md | Add link to the new GraphQL Plugins page. |
| Cargo.toml | Bump workspace version to 0.1.12. |
| Cargo.lock | Bump crate versions to 0.1.12. |
| CHANGELOG.md | Add v0.1.12 changelog entry describing new features and removals. |
| .gitleaks.toml | Add gitleaks config + allowlist for generated and example paths. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
- Fix auth fallback: plugin.default_auth() returning None no longer blocks the auth_port fallback path (was unreachable) - Fix auth error handling: auth port failure now returns AuthenticationFailed error instead of warn-and-proceed silently - Fix budget race: double-check pattern under write lock prevents two concurrent slow-path inits from overwriting each other's budget state - Add PluginBudget::config() accessor; wire reactive_backoff_ms into validate_body so throttle back-off uses exponential/config-aware delay when a budget is available, falling back to fixed-clamp otherwise - Fix graphql-plugins.md: correct TokenSet fields (access_token, refresh_token, expires_at, scopes), load_token/refresh_token sigs, GraphQlService::new signature, BuildError return type, CostThrottleConfig field name (restore_per_sec) and defaults, EnvAuthPort None behaviour, and env-var template expansion attribution
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 43 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
book/src/graph/adapters.md
Outdated
| use stygian_graph::adapters::graphql::GraphQlService; | ||
| use stygian_graph::ports::auth::{EnvAuthPort, ErasedAuthPort}; | ||
|
|
||
| let service = GraphQlService::new(registry) | ||
| .with_auth_port(Arc::new(EnvAuthPort::new("MY_API_TOKEN")) as Arc<dyn ErasedAuthPort>); |
There was a problem hiding this comment.
The code sample constructs GraphQlService with GraphQlService::new(registry), but the actual constructor takes (GraphQlConfig, Option<Arc<GraphQlPluginRegistry>>) (see crates/stygian-graph/src/adapters/graphql.rs). Update the docs snippet to use the real signature (and show how to pass the registry via Some(Arc::new(registry))).
| use stygian_graph::adapters::graphql::GraphQlService; | |
| use stygian_graph::ports::auth::{EnvAuthPort, ErasedAuthPort}; | |
| let service = GraphQlService::new(registry) | |
| .with_auth_port(Arc::new(EnvAuthPort::new("MY_API_TOKEN")) as Arc<dyn ErasedAuthPort>); | |
| use stygian_graph::adapters::graphql::{GraphQlConfig, GraphQlService}; | |
| use stygian_graph::ports::auth::{EnvAuthPort, ErasedAuthPort}; | |
| let service = GraphQlService::new( | |
| GraphQlConfig::default(), | |
| Some(Arc::new(registry)), | |
| ) | |
| .with_auth_port(Arc::new(EnvAuthPort::new("MY_API_TOKEN")) as Arc<dyn ErasedAuthPort>); |
book/src/graph/graphql-plugins.md
Outdated
| Register it the same way as any built-in plugin: | ||
|
|
||
| ```rust | ||
| use stygian_graph::adapters::graphql::GraphQlPluginRegistry; |
There was a problem hiding this comment.
This snippet imports GraphQlPluginRegistry from stygian_graph::adapters::graphql, but the type lives under stygian_graph::application::graphql_plugin_registry::GraphQlPluginRegistry (and this example also needs use std::sync::Arc;). As written, the example won’t compile.
| use stygian_graph::adapters::graphql::GraphQlPluginRegistry; | |
| use std::sync::Arc; | |
| use stygian_graph::application::graphql_plugin_registry::GraphQlPluginRegistry; |
| fn validate_body(body: &Value, budget: Option<&PluginBudget>) -> Result<()> { | ||
| // Throttle check takes priority so callers can retry with backoff. | ||
| if let Some(retry_after_ms) = Self::detect_throttle(body) { | ||
| if Self::detect_throttle(body).is_some() { | ||
| let retry_after_ms = budget.map_or_else( | ||
| || Self::throttle_backoff(body), | ||
| |b| reactive_backoff_ms(b.config(), body, 0), | ||
| ); |
There was a problem hiding this comment.
reactive_backoff_ms supports exponential backoff via its attempt parameter, but validate_body always passes attempt = 0, so retries will never increase the delay. Either (a) plumb an attempt counter from the retrying caller into this path, or (b) switch to returning a deterministic base delay here and let the retry loop apply exponential backoff externally.
| // ── 4b. Lazy-init and acquire per-plugin budget ─────────────────── | ||
| let maybe_budget: Option<PluginBudget> = if let Some(ref p) = plugin { | ||
| if let Some(throttle_cfg) = p.cost_throttle_config() { | ||
| let name = p.name().to_string(); | ||
| let budget = { | ||
| let read = self.budgets.read().await; | ||
| if let Some(b) = read.get(&name) { | ||
| b.clone() | ||
| } else { | ||
| drop(read); | ||
| // Slow path: initialise under write lock with double-check | ||
| // to prevent two concurrent requests both inserting a fresh | ||
| // budget and one overwriting any updates the other has applied. | ||
| let mut write = self.budgets.write().await; | ||
| write | ||
| .entry(name) | ||
| .or_insert_with(|| PluginBudget::new(throttle_cfg)) | ||
| .clone() | ||
| } | ||
| }; | ||
| pre_flight_delay(&budget).await; | ||
| Some(budget) | ||
| } else { |
There was a problem hiding this comment.
The proactive throttle gate (pre_flight_delay) does not reserve/debit any points before sending the request. With concurrent GraphQlService::execute calls for the same plugin, multiple requests can pass the pre-flight check simultaneously and still get throttled, undermining the goal of proactive throttling. Consider adding a reservation step (decrement budget by an estimated cost) or serializing requests per budget while the projected budget is below the threshold.
| /// Attach a runtime auth port. | ||
| /// | ||
| /// When set, the port's `erased_resolve_token()` will be called to obtain | ||
| /// a bearer token whenever `params.auth` is absent and the plugin supplies | ||
| /// no `default_auth`. | ||
| /// | ||
| /// # Example | ||
| /// | ||
| /// ```no_run | ||
| /// use std::sync::Arc; | ||
| /// use stygian_graph::adapters::graphql::{GraphQlService, GraphQlConfig}; | ||
| /// use stygian_graph::ports::auth::{EnvAuthPort, ErasedAuthPort}; | ||
| /// | ||
| /// let auth: Arc<dyn ErasedAuthPort> = Arc::new(EnvAuthPort::new("API_TOKEN")); | ||
| /// let service = GraphQlService::new(GraphQlConfig::default(), None) | ||
| /// .with_auth_port(auth); | ||
| /// ``` | ||
| #[must_use] | ||
| pub fn with_auth_port(mut self, port: Arc<dyn ErasedAuthPort>) -> Self { | ||
| self.auth_port = Some(port); | ||
| self | ||
| } |
There was a problem hiding this comment.
with_auth_port introduces new auth-selection behavior (fallback to the runtime port when params.auth and plugin.default_auth() are absent), but there’s no unit test exercising this path in graphql.rs (this file already has extensive request-capture tests). Adding a test that asserts a request uses the port-provided bearer token would prevent regressions.
- Add `estimated_cost_per_request` field to `CostThrottleConfig` (default 100.0) - Add `pending` field to `LiveBudget`; deduct from projected_available() - Replace `pre_flight_delay` with `pre_flight_reserve` (returns reserved cost) - Add `release_reservation` called at every exit path (success + error) - Per-request reserve/release replaces the single pre-execute delay - Add `concurrent_reservations_reduce_projected_available` test NOTE: explicit call-site cleanup used in place of RAII guard because AsyncDrop is not stabilised in Rust 1.93.1 (stable). TODOs added at each site to revisit once AsyncDrop lands.
v0.1.12
Added
AuthPort— trait for runtime credential management:load_token(), expiry detection, andrefresh_token();ErasedAuthPortobject-safe wrapper with blanket impl;EnvAuthPortconvenience implementation;resolve_tokenhelperCostThrottleConfig+LiveBudget/PluginBudget— proactive point-budget tracking for cost-bearing GraphQL APIs (Shopify Admin API pattern);pre_flight_delaysleeps before a request if the projected budget is too low;update_budgetparsesextensions.cost.throttleStatus;reactive_backoff_msfor exponential back-off on throttle responsesGenericGraphQlPlugin+GenericGraphQlPluginBuilder— fluent builder API to construct aGraphQlTargetPluginwithout writing a dedicated struct; supportsname,endpoint,bearer_auth,auth,header,headers,cost_throttle,page_size,descriptionGraphQlService::with_auth_port()— attach a runtimeErasedAuthPort; token resolved lazily per request with automatic refresh on expiryGraphQlTargetPlugin::cost_throttle_config()— new default method; plugins opt in to proactive throttling by returningSome(CostThrottleConfig)examples/pipelines/github/adapters.mdGraphQL section,SUMMARY.mdlinkRemoved
JobberPluginandjobber_integrationtests — consumer-specific plugins belong in the consuming application; useGenericGraphQlPluginor implementGraphQlTargetPlugindirectlyexamples/pipelines/jobber/)232 tests passing · zero clippy warnings ·
cargo fmtclean